9  Синтаксический парсинг

Основные этапы NLP включают в себя токенизацию, морфологический и синтаксический анализ, а также анализ семантики и прагматики. В этом уроке речь пойдет про первые три этапа. Мы научимся разбивать текст на токены (слова), определять морфологические характеристики слов и находить их начальные формы (леммы), а также анализировать структуру предложения с использованием синтаксических парсеров.

9.1 Токенизация

Токенизация — процесс разделения текста на составляющие (их называют «токенами»). Токенами могут быть слова, символьные или словесные энграмы (n-grams), то есть сочетания символов или слов, даже предложения или параграфы.

Токенизировать можно в базовом R с использованием регулярных выражений, и Jockers (2014) прекрасно показывает, как это можно делать. Но мы воспользуемся двумя пакетами, которые предназначены специально для работы с текстовыми данными и разделяют идеологию tidyverse: tidytext (Silge и Robinson 2017) и tokenizers (Hvitfeldt и Silge 2022).

library(tidyverse) 
library(tidytext)
library(tokenizers)

Для анализа воспользуемся датасетом c латинским текстом “Записок о Галльской войне”, который мы подготовили в предыдущем уроке. Его можно забрать отсюда.

load("../data/caesar.RData")
caesar <- caesar |> 
  rename(text = value) |> 
  select(-link)

caesar

Функция unnest_tokens() из пакета tidytext принимает на входе тиббл, название столбца, в котором хранится текст для токенизации, а также название нового столбца, куда будут “сложены” отдельные токены (зачастую это слова, но не обязательно).

unnest_tokens(
  tbl,
  output,
  input,
  token = "words",
  format = c("text", "man", "latex", "html", "xml"),
  to_lower = TRUE,
  drop = TRUE,
  collapse = NULL,
  ...
)

Аргумент token принимает следующие значения:

  • “words” (default),
  • “characters”,
  • “character_shingles”,
  • “ngrams”,
  • “skip_ngrams”,
  • “sentences”,
  • “lines”,
  • “paragraphs”,
  • “regex”,
  • “ptb” (Penn Treebank).

Используя уже знакомую функцию map, можно запустить unnest_tokens() с разными аргументами:

test <- tibble(text = "Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur. Hi omnes lingua, institutis, legibus inter se differunt.")
params <- tribble(
  ~tbl, ~output, ~input, ~token,
  test, "word", "text", "words", 
  test, "sentence", "text", "sentences",
  test, "char", "text", "characters", 
)

params
params |> 
  pmap(unnest_tokens) 
[[1]]
# A tibble: 29 × 1
   word    
   <chr>   
 1 gallia  
 2 est     
 3 omnis   
 4 divisa  
 5 in      
 6 partes  
 7 tres    
 8 quarum  
 9 unam    
10 incolunt
# ℹ 19 more rows

[[2]]
# A tibble: 2 × 1
  sentence                                                                      
  <chr>                                                                         
1 gallia est omnis divisa in partes tres, quarum unam incolunt belgae, aliam aq…
2 hi omnes lingua, institutis, legibus inter se differunt.                      

[[3]]
# A tibble: 166 × 1
   char 
   <chr>
 1 g    
 2 a    
 3 l    
 4 l    
 5 i    
 6 a    
 7 e    
 8 s    
 9 t    
10 o    
# ℹ 156 more rows

Следующие значения аргумента token требуют также аргумента n:

params <- tribble(
  ~tbl, ~output, ~input, ~token, ~n,
  test, "ngram", "text", "ngrams", 3,
  test, "shingles", "text", "character_shingles", 3
)

params  |> 
  pmap(unnest_tokens)  |> 
  head()
[[1]]
# A tibble: 27 × 1
   ngram                
   <chr>                
 1 gallia est omnis     
 2 est omnis divisa     
 3 omnis divisa in      
 4 divisa in partes     
 5 in partes tres       
 6 partes tres quarum   
 7 tres quarum unam     
 8 quarum unam incolunt 
 9 unam incolunt belgae 
10 incolunt belgae aliam
# ℹ 17 more rows

[[2]]
# A tibble: 164 × 1
   shingles
   <chr>   
 1 gal     
 2 all     
 3 lli     
 4 lia     
 5 iae     
 6 aes     
 7 est     
 8 sto     
 9 tom     
10 omn     
# ℹ 154 more rows

Дальше мы будем работать со словами, поэтому сохраним токенизированный текст “Записок” в виде “опрятного” датасета (одно наблюдение - один ряд).

caesar_tokens <- caesar |> 
  unnest_tokens("word", "text")

caesar_tokens

При работе с данными в текстовом формате unnest_tokens() опирается на пакет tokenizers, из которого в нашем случае подтягивает функцию tokenize_words. У этой функции есть несколько полезных аргументов: strip_non_alphanum (удаляет пробельные символы и пунктуацию), strip_punct (удаляет пунктуацию), strip_numeric (удаляет числа).

Эти аргументы мы тоже можем задать через unnest_tokens(), поскольку у функции есть аргумент ... (загляните в документацию, чтобы убедиться).

caesar |> 
  unnest_tokens("word", "text", strip_punct = FALSE)

9.2 Лемматизация и частеречная разметка

Лемматизация – приведение слов к начальной форме (лемме). Как правило, она сопровождается частеречной разметкой слов (POS-tagging). В R это умеет делать, например, пакет udpipe (Universal Dependencies Pipeline). Он позволяет работать со множеством языков (всего 65), для многих из которых представлено несколько моделей, обученных на разных данных.

Прежде всего нужно выбрать и загрузить модель (список); в нашем случае это модель Perseus, но можно попробовать и другие доступные на сайте https://universaldependencies.org/.

library(udpipe)

#  скачиваем модель в рабочую директорию
udpipe_download_model(language = "latin-perseus")

# загружаем модель
latin_perseus <- udpipe_load_model(file = "latin-perseus-ud-2.5-191206.udpipe")

# аннотируем
caesar_annotate <- udpipe_annotate(latin_perseus, caesar$text)

Результат возвращается в формате CONLL-U; это широко применяемый формат представления результат морфологического и синтаксического анализа текстов. Вот пример разбора предложения:

Cтроки слов содержат следующие поля:

  1. ID: индекс слова, целое число, начиная с 1 для каждого нового предложения; может быть диапазоном токенов с несколькими словами.
  2. FORM: словоформа или знак препинания.
  3. LEMMA: Лемма или основа словоформы.
  4. UPOSTAG: универсальный тег части речи.
  5. XPOSTAG: тег части речи для конкретного языка.
  6. FEATS: список морфологических характеристик.
  7. HEAD: заголовок текущего токена, который является либо значением ID, либо нулем (0).
  8. DEPREL: Universal Stanford dependency relation к (root iff HEAD = 0) или определенному зависящему от языка подтипу.
  9. DEPS: Список вторичных зависимостей.
  10. MISC: любая другая аннотация.

Для работы данные удобнее трансформировать в прямоугольный формат.

caesar_pos <- as_tibble(caesar_annotate) |> 
  select(-paragraph_id)

caesar_pos

9.3 Обучение модели

Можно заметить, что модель Perseus 2.5 справилась не безупречно: все бельги оказались женского рода, а кельты и вовсе признаны глаголом. Есть ошибки в падежах и числах: например, “provinciae” в четвертом предложении, конечно, не именительный, а родительный падеж. Множество топонимов не опознано в качестве имен собственных.

Здесь есть два пути. Первый: пробовать другие модели, доступные в пакете udpipe. Например, для латыни это PROIEl, обученная не только на классических авторах, но и на Вульгате, или ITTB, обученная на сочинениях Фомы.

Второй путь - обучить модель самостоятельно. Например, для трибанка Perseus доступны более свежие версии (2.13 на момент написания этой главы) на GitHub. Вот некоторые изменения:

  • появилась метка dep_rel для ablativus absolutus (advcl:abs);
  • исправлены аннотации для супина (VerbForm=Conv, Aspect=Prosp), а также герундия и герундива (VerbForm=Part, Aspect=Prosp);
  • добавлен тип для местоимения (PronType) и вид для глагола (Aspect) и др.

Инструкцию по обучению модели можно найти здесь. По сути трибанк представляет собой коллекцию проверенных вручную CONLL-U файлов, которые передаются нейросети. Следуя этой инструкции и используя трибанк Perseus 2.13, мы обучили новую модель (это заняло около 8 часов на персональном компьютере), которую можно загрузить и использовать для аннотации.

Надо иметь в виду, что само по себе обновление трибанка еще не гарантирует того, что модель будет лучше справляться с парсингом: многое зависит от параметров обучения. В нашем случае, впрочем, некоторые улучшения есть: например, “provinciae” корректно опознано как родительный падеж. Но есть и потери: “fortissimi” в том же предложении выше - это nominativus pluralis, который ошибочно опознан как генитив единственного числа.

latin_perseus_new <- udpipe_load_model("../latin_model/la_perseus-2.13-20231115.udpipe")

caesar_annotate2 <- udpipe_annotate(latin_perseus_new, caesar$text[1])

caesar_pos2 <- as_tibble(caesar_annotate2) |> 
  select(-paragraph_id)
caesar_pos2

Пока для наших задач достигнутой точности хватит, но можно попробовать построить нейросеть с более сложной архитектурой. Например, в 2024 г. такая архитектура была предложена и для латинского языка.

9.4 Поле UPOS

Морфологическая аннотация, которую мы получили, дает возможность выбирать и группировать различные части речи. Например, местоимения.

caesar_pos2 |> 
  filter(upos == "PRON") |> 
  select(token, lemma, upos, xpos)

Посчитать части речи можно так:

upos_counts <- caesar_pos2 |> 
  group_by(upos) |> 
  count() |> 
  arrange(-n)

upos_counts

Столбиковая диаграмма позволяет наглядно представить результаты подсчетов:

upos_counts |> 
  ggplot(aes(x = reorder(upos, n), y = n, fill = upos)) +
  geom_bar(stat = "identity", show.legend = F) +
  coord_flip() +
  labs(x = NULL) +
  theme_bw() 

Отберем наиболее частотные имена и имена собственные.

nouns <- caesar_pos2  |> 
  filter(upos %in% c("NOUN", "PROPN")) |> 
  count(lemma) |> 
  arrange(-n) 

nouns
library(wordcloud)
Loading required package: RColorBrewer
library(RColorBrewer)

pal <- RColorBrewer::brewer.pal(8, "Dark2")

wordcloud(nouns$lemma, nouns$n, colors = pal, max.words = 130)

9.5 Поле FEATS

Допустим, нам нужны не все местоимения, а лишь определенные их формы: например, относительные.

rel_pron <- caesar_pos2  |> 
  filter(str_detect(feats, "PronType=Rel")) 

rel_pron

Посмотрим на некоторые местоимения в контексте. Для этого добавим html-теги:

highlight_string <- function(idx) str_replace_all(
  rel_pron$sentence[idx], 
  str_glue("(?<= ){rel_pron$token[idx]}(?=\\W)"),
  str_glue("<mark>{rel_pron$token[idx]}</mark>"))

highlight_string(1)

[1] “Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur.”

highlight_string(13)

[1] “In eo itinere persuadet Castico, Catamantaloedis filio, Sequano, cuius pater regnum in Sequanis multos annos obtinuerat et a senatu populi Romani amicus appellatus erat, ut regnum in civitate sua occuparet, quod pater ante habuerit;”

9.6 Поле XPOS

Чтение xpos требует сноровки: например причастие sublata там описывается так: v-srppfb-, где

  • v = verbum;
  • - на месте лица;
  • s = singularis;
  • r = perfectum (не p, потому что p = praesens);
  • p = participium;
  • p = passivum;
  • f = femininum;
  • b = ablativus (не a, потому что a = accusativus).

Сравним с описанием личной формы глагола differunt v3ppia---:

  • v = verbum;
  • 3 = 3. persona;
  • p = pluralis;
  • p = praesens;
  • i = indicativus;
  • a = activum;
  • -- на месте рода и падежа, т.к. форма неличная.

Последнее “место” (Degree) у глаголов всегда свободно; в первой книге там стоит s (superlativus) лишь у florentissimis, что явно ошибка, потому что это не глагол.

На заметку

Спецификацию всех xpos-тегов для латинского языка можно найти по ссылке.

Для удобства разобьем xpos на 9 столбцов.

caesar_pos2_sep <- caesar_pos2 |> 
  separate(xpos, into = c("POS", "xpos"), sep = 1) |> 
  separate(xpos, into = c("persona", "xpos"), sep = 1) |> 
  separate(xpos, into = c("numerus", "xpos"), sep = 1) |> 
  separate(xpos, into = c("tempus", "xpos"), sep = 1) |> 
  separate(xpos, into = c("modus", "xpos"), sep = 1) |> 
  separate(xpos, into = c("vox", "xpos"), sep = 1) |> 
  separate(xpos, into = c("genus", "xpos"), sep = 1) |> 
  separate(xpos, into = c("casus", "gradus"), sep = 1)

caesar_pos2_sep

Эти столбцы тоже можно использовать для поиска конкретных признаков. Посмотрим, например, в каком числе и падеже чаще всего стоит относительное местоимения.

pron_rel_sum <- caesar_pos2_sep  |> 
  filter(upos == "PRON") |> 
  filter(str_detect(feats, "PronType=Rel")) |> 
  group_by(numerus, casus) |> 
  summarise(n = n()) |> 
  arrange(-n)

pron_rel_sum

Для удобства преобразуем сокращения.

pron_rel_sum <- pron_rel_sum |> 
  filter(casus != "-") |> 
  mutate(casus = case_when(casus == "n" ~ "nom",
                           casus == "g" ~ "gen",
                           casus == "d" ~ "dat",
                           casus == "a" ~ "acc",
                           casus == "b" ~ "abl")) |> 
  mutate(numerus = case_when(numerus == "s" ~ "sing",
                              numerus == "p" ~ "plur"))

pron_rel_sum

Функция facet_wrap позволяет разбить график на две части на основании значения переменной numerus.

pron_rel_sum |> 
  ggplot(aes(casus, n, fill = casus)) +
  geom_bar(stat = "identity", show.legend = FALSE) +
  coord_flip() +
  theme_light() +
  facet_wrap(~numerus) +
  labs(x = NULL, y = NULL, title = "Относительные местоимения в BG 1-7")

9.7 Поле DEP_REl

Аналогичным образом можно отбирать синтаксические признаки и их комбинации, а также визуализировать деревья зависимостей для отдельных предложений.

Дерево зависимостей – это направленный граф, который имеет единственную корневую вершину (сказуемое главного предложения) без входящих дуг (рёбер), при этом все остальные вершины имеют ровно одну входящую дугу. Иными словами, каждое слово зависит от другого, но только от одного. Это выглядит примерно так:

library(textplot)

sent <- caesar_pos |> 
  filter(doc_id == "doc1", sentence_id == 10) 

sent |> 
  distinct(sentence) |> 
  pull(sentence) 
[1] "Apud Helvetios longe nobilissimus fuit et ditissimus Orgetorix."
textplot_dependencyparser(sent, size = 3)

Можно поспорить с тем, что nobilissiumus и ditissimus - это глаголы, хотя модель Perseus 2.5 верно опознала их в качестве именной части сказуемого при подлежащем “Оргеториг”. Информация, которая на графе представлена стрелками, хранится в таблице в полях token_id и head_token_id и dep_rel. Корневой токен всегда имеет значение 0, то есть ни от чего не зависит.

sent |> 
  select(token_id, token, head_token_id, dep_rel)

Правила синтаксической разметки для латинского языка доступны по ссылке, а расшифровку сокращений (для всех языков) надо смотреть здесь.

Вообще латынь (как и древнегреческий ) – не очень ресурсный язык; для многих языков доступны хорошие предобученные модели.

9.8 Совместная встречаемость слов

Функция cooccurence() из пакета udpipe позволяет выяснить, сколько раз некий термин встречается совместно с другим термином, например:

  • слова встречаются в одном и том же документе/предложении/параграфе;
  • слова следуют за другим словом;
  • слова находятся по соседству с другим словом на расстоянии n слов.

Код ниже позволяет выяснить, какие существительные встречаются в одном предложении:

caesar_subset <-  subset(caesar_pos2, upos == "NOUN")
cooc <- cooccurrence(caesar_subset, term = "lemma", group = c("doc_id", "sentence_id")) |>   as_tibble() |> 
  filter(cooc > 25)

cooc

Этот результат легко визуализировать, используя пакет ggraph (подробнее о нем мы будем говорить в следующих уроках):

library(igraph)
library(ggraph)

wordnetwork <- graph_from_data_frame(cooc)
ggraph(wordnetwork, layout = "fr") +
  geom_edge_link(aes(width = cooc), alpha = 0.8, edge_colour = "grey90", show.legend=FALSE) +
  geom_node_label(aes(label = name), col = "#1f78b4", size = 4) +
  theme_void() +
  labs(title = "Совместная встречаемость существительных", subtitle = "De Bello Gallico 1-7")

Чтобы узнать, какие слова чаще стоят рядом, используем ту же функцию, но с другими аргументами:

cooc2 <- cooccurrence(caesar_subset$lemma, relevant = caesar_subset$upos %in% c("NOUN", "ADJ"), skipgram = 1) |> 
  as_tibble() |> 
  filter(cooc > 10)

cooc2
wordnetwork <- graph_from_data_frame(cooc2)

ggraph(wordnetwork, layout = "fr") +
  geom_edge_link(aes(width = cooc), edge_colour = "grey90", edge_alpha=0.8, show.legend = F) +
  geom_node_label(aes(label = name), col = "#1f78b4", size = 4) +
  labs(title = "Слова, стоящие рядом в тексте", subtitle = "De Bello Gallico 1-7") +
  theme_void()

Hvitfeldt, Emil, и Julia Silge. 2022. Supervised Machine Learning for Text Analysis in R. Taylor; Francis.
Jockers, Matthew L. 2014. Text Analysis with R for Students of Literature. Springer.
Silge, Julia, и David Robinson. 2017. Text Mining with R. O’Reilly. http://www.tidytextmining.com.